认识 eslint 插件
Rollup 是一款面向 ES 模块(ESM) 的打包工具。其核心优势是「摇树(Tree Shaking)」和打包产物简洁。
一、打包核心原理
1. 入口分析与依赖图构建
Rollup 从配置文件中 input 配置的入口文件开始,通过静态分析 ES 模块的 import/export 语句,递归遍历所有依赖模块,不会执行代码(这是 Tree Shaking 的基础),最终构建出一个「模块依赖图」,记录所有模块的依赖关系、导出内容、导入内容。
2. 插件生命周期执行(核心扩展点)
Rollup 本身核心功能有限,所有自定义转换、路径解析、文件加载等能力都依赖插件。在打包的各个阶段,Rollup 会按顺序调用插件的钩子函数,对模块进行处理(这部分是实现你需求的核心)。
3. AST 解析与转换
Rollup 内置 acorn 解析器,会将每个模块的代码解析为 ESTree 规范的抽象语法树(AST)。所有代码的修改、替换、移除,都不是直接操作字符串(易出错),而是通过操作 AST 节点实现的,修改完成后再将 AST 转换回可执行的 JS 代码。
4. 树摇(Tree Shaking)
基于 ES 模块的「静态特性」( import/export 只能在模块顶层,不能动态判断、不能嵌套在代码块中),Rollup 会分析模块依赖图,移除所有未被使用的导出内容和死代码,减少打包产物体积。你的需求 2、3 本质上是「增强版树摇」,需要自定义插件辅助实现。
5. 产物生成与输出
根据配置文件 output 中的格式( es/cjs/umd 等)、输出路径、拆分策略等,将处理后的所有模块合并,生成最终的打包文件,若配置了 sourcemap ,还会生成对应的源码映射文件。
二、插件工作原理
1. 插件的本质
Rollup 插件是一个「返回钩子对象的函数」,函数可以接收插件配置参数,返回的对象中包含了 Rollup 打包生命周期的各种钩子, Rollup 在对应阶段会自动调用这些钩子。
function customRollupPlugin(options = {}) {
return {
// 插件名称,用于报错信息和日志标识,必填
name: 'custom-transform-plugin',
// 各种生命周期钩子
options(config) {
/* 修改打包配置 */
},
resolveId(source) {
/* 解析模块真实路径 */
},
transform(code, id) {
/* 转换模块代码,核心实现需求 */
},
generateBundle(outputOptions, bundle) {
/* 处理生成后的产物 */
},
};
}
2. 核心钩子
options:打包开始前执行,用于修改 Rollup 原始配置(比如补充输出格式)。resolveId:解析模块 ID(路径),用于处理别名、第三方包、虚拟模块(比如返回自定义模块 ID 屏蔽原始模块)。load:根据模块 ID 加载模块内容,用于加载非 JS 文件、虚拟模块内容(比如返回自定义代码替换原始模块)。transform:加载模块后、AST 分析前执行,接收模块代码和模块 ID,返回修改后的代码和 sourcemap。这是实现「代码转换 / 移除」的核心钩子,我们会在这里操作 AST 完成所有需求
3. 插件实现的核心技术
要实现代码的精准转换 / 移除,不能直接用字符串替换(易出现边界错误,比如误匹配变量名),核心依赖 3 个工具库:
estree-walker:遍历 ESTree 规范的 AST 节点,方便找到需要修改 / 移除的节点。magic-string:安全修改代码字符串,同时保留 sourcemap(避免打包后无法映射到源码,难以调试)。@rollup/pluginutils:提供实用工具(比如 createFilter),用于过滤需要处理的文件(比如只处理 .js/.ts 文件,忽略 node_modules)
三、小插件: rollup 版移除打印狗
1. 安装依赖
# Rollup 内置,但单独安装可保证版本一致,用于解析代码为 AST
npm install --save-dev estree-walker magic-string @rollup/pluginutils acorn
若需要处理 TypeScript 文件,还需要安装 @rollup/plugin-typescript 和 typescript,且自定义插件要放在 typescript 插件之后执行
2. 代码片段
// 自定义 Rollup 插件:prod环境下代码转换与死代码移除
import acorn from 'acorn';
import { walk } from 'estree-walker';
import MagicString from 'magic-string';
import { createFilter } from '@rollup/pluginutils';
function prodCodeTransformPlugin(options = {}) {
// 过滤需要处理的文件:默认处理 .js/.ts 文件,忽略 node_modules
const filter = createFilter(
options.include || ['**/*.js', '**/*.ts'],
options.exclude || ['node_modules/**'],
);
// 判断是否为 production 环境
const isProduction = process.env.NODE_ENV === 'production';
return {
name: 'qqi-rollup-plugin-remove-dog', // 插件名称,必填
/**
* 核心转换钩子:处理每个模块的代码
* @param {string} code - 模块原始代码
* @param {string} id - 模块 ID(文件路径)
* @returns {object|void} - 修改后的代码和 sourcemap
*/
transform(code, id) {
// 1. 非 production 环境、无需处理的文件,直接返回
if (!isProduction || !filter(id)) return;
// 2. 初始化 MagicString,用于安全修改代码并保留 sourcemap
const magicStr = new MagicString(code);
// 3. 解析代码为 AST(ESTree 规范)
let ast;
try {
ast = acorn.parse(code, {
ecmaVersion: 'latest',
sourceType: 'module', // 按 ES 模块解析
ranges: true, // 保留节点的位置范围(start/end),方便 magicStr 操作
locations: true,
});
} catch (err) {
this.warn(`解析模块 ${id} 失败:${err.message}`);
return;
}
// 标记:是否存在 dun = false(用于后续移除 if (dun) 代码块)
let hasDunFalse = false;
// 存储需要移除的代码节点范围(避免重复操作)
const removeRanges = new Set();
// 4. 遍历 AST,处理所有需求
walk(ast, {
// 进入 AST 节点时触发(核心处理逻辑)
enter(node) {
/************************** 需求 1:转换 Dog 导入 **************************/
if (
node.type === 'ImportDeclaration' &&
node.source.value === '@qqi/log'
) {
// 遍历 import 声明的所有命名导入
node.specifiers.forEach(specifier => {
// 匹配:import { Dog } from '@qqi/log'
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'Dog'
) {
// 找到 Dog 对应的代码起始和结束位置,替换为 DogVirtual
const importedNameStart = specifier.imported.start;
const importedNameEnd = specifier.imported.end;
magicStr.overwrite(
importedNameStart,
importedNameEnd,
'DogVirtual',
);
}
});
}
/************************** 需求 2:移除 dog 相关导入和调用 **************************/
// 2.1 移除 import { dog } from 'xxx'
if (node.type === 'ImportDeclaration') {
let needRemoveImport = false;
// 标记是否只导入了 dog(若有其他导入,只移除 dog 对应的部分)
let onlyDogImport = node.specifiers.length === 1;
node.specifiers.forEach((specifier, index) => {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'dog'
) {
if (onlyDogImport) {
// 整个 import 语句都移除
removeRanges.add(`${node.start}-${node.end}`);
needRemoveImport = true;
} else {
// 只移除 dog 对应的部分(处理逗号分隔)
const specStart = specifier.start;
const specEnd = node.specifiers[index + 1]
? node.specifiers[index + 1].start - 1
: node.end;
removeRanges.add(`${specStart}-${specEnd}`);
}
}
});
// 若标记了移除整个 import,直接跳过后续处理
if (needRemoveImport) return;
}
// 2.2 移除 dog('xxx')、dog.warn('xxx')、dog.error('xxx')
if (node.type === 'CallExpression') {
let isDogCall = false;
// 匹配:dog('xxx')(直接调用)
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'dog'
) {
isDogCall = true;
}
// 匹配:dog.warn('xxx')、dog.error('xxx')(成员调用)
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'dog' &&
['warn', 'error', 'info', 'type'].includes(
node.callee.property.name,
)
) {
isDogCall = true;
}
// 标记需要移除的调用语句范围
if (isDogCall) {
removeRanges.add(`${node.start}-${node.end}`);
}
}
/************************** 需求 3:移除 dun 相关导出和 if 代码块 **************************/
// 3.1 匹配:export const dun = false
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'VariableDeclaration' &&
node.declaration.kind === 'const'
) {
node.declaration.declarations.forEach(declaration => {
if (
declaration.id.name === 'dun' &&
declaration.init &&
declaration.init.type === 'Literal' &&
(declaration.init.value === false ||
declaration.init.value === true)
) {
// 标记存在 dun = false,且移除该导出语句
hasDunFalse = true;
removeRanges.add(`${node.start}-${node.end}`);
}
});
}
// 3.2 匹配:if (dun) { ... }(仅当 hasDunFalse 为 true 时移除)
if (hasDunFalse && node.type === 'IfStatement') {
const testNode = node.test;
if (testNode.type === 'Identifier' && testNode.name === 'dun') {
// 移除整个 if 语句块
removeRanges.add(`${node.start}-${node.end}`);
}
}
},
});
// 5. 执行代码移除(处理所有标记的移除范围)
Array.from(removeRanges).forEach(range => {
const [start, end] = range.split('-').map(Number);
magicStr.remove(start, end);
});
// 6. 返回修改后的代码和 sourcemap
return {
code: magicStr.toString(),
map: magicStr.generateMap({ hires: true }), // 生成高精度 sourcemap
};
},
};
}
module.exports = prodCodeTransformPlugin;
3. 插件使用方式
在 Rollup 配置文件(rollup.config.js)中引入并使用该插件:
const prodCodeTransform = require('./prodCodeTransformPlugin');
const { terser } = require('rollup-plugin-terser'); // 可选,production 环境压缩代码
module.exports = {
input: 'src/index.js', // 你的入口文件
output: {
file: 'dist/bundle.js',
format: 'es', // 输出 ES 模块
},
plugins: [
// 引入自定义插件,放在其他转换插件之后(比如 ts 插件)
prodCodeTransform(),
// 可选:production 环境压缩代码(建议添加)
process.env.NODE_ENV === 'production' && terser(),
],
};